30 — 动画星系

Three.js Journey · Bruno Simon · 难度:简单

30 — 动画星系 课程海报

介绍

你也可以在 particles 中使用 shader。正如我们在 Particles 课程中看到的,出于性能原因,为 geometry 的每个 vertex 做动画并不是高效的方案。这时 GPU 就派上了用场,可以直接在 vertex shader 中为这些 vertex 做动画。

在本课中,我们将从 particle 星系开始。我们会用 vertex shader 为 particles 做动画,让星星旋转,但根据距中心的距离以不同速度旋转;同时我们会在 particles 中绘制图案,来替代那些难看的方块。

设置

起始项目与 Galaxy Generator 课程的起始项目几乎相同。唯一的区别是缺少了自转公式,因为我们将在 shader 中完成自转动画。

用 ShaderMaterial 替换 PointsMaterial

目前 particles 使用的是 PointsMaterial,但如果想编写自己的 shader,我们就需要使用 ShaderMaterial。

将 PointsMaterial 替换为 ShaderMaterial:

material = new THREE.ShaderMaterial({
    // ...
})

如果你查看日志,应该会看到两条警告,提示 ShaderMaterial 既不支持 size 也不支持 sizeAttenuation。我们需要自行添加这些特性。目前,先移除这些属性:

material = new THREE.ShaderMaterial({
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    vertexColors: true
})

此时此刻,有些人可能会看到 particles 像微小的红点,有些人可能会看到黑屏。这取决于当未提供 size 时,你的 GPU 如何处理 particles。我们不会在这上面浪费时间,因为无论如何我们都会设置 size,届时每个人都应该能看到 particles。

显然,我们需要提供自己的 shader。添加以下 vertexShader:

material = new THREE.ShaderMaterial({

    // ...

    vertexShader: `
        void main()
        {
            /**
             * Position
             */
            vec4 modelPosition = modelMatrix * vec4(position, 1.0);
            vec4 viewPosition = viewMatrix * modelPosition;
            vec4 projectedPosition = projectionMatrix * viewPosition;
            gl_Position = projectedPosition;

            /**
             * Size
             */
            gl_PointSize = 2.0;
        }
    `
})

shader 的开头与我们之前见过的一样。我们依次使用 modelMatrix、viewMatrix 和 projectionMatrix 来更新 position。但随后,我们给一个名为 gl_PointSize 的新变量赋值为 2.0。

gl_PointSize 正是你所想的那样。particles 将具有 2x2 的大小,无论 camera 距离多远,你都应该看到 2x2 大小的 particles。

这里的单位是 fragment,如果你使用的是像素比为 1 的普通屏幕,你会得到 2 像素乘 2 像素的大小,因为 1 个 fragment = 1 个像素。但如果你使用的是像素比更高的屏幕,比如 retina 屏,1 个 fragment 会比 1 个像素小,你看到的 particles 也会更小。我们稍后会修复这一点,以便在任何像素比下都能获得一致的结果。

在我们改进 particles 大小之前,先来更改颜色。

目前 particles 是红色的,因为我们没有提供任何 fragmentShader,而 Three.js 会使用一个输出红色的默认 shader。

添加以下输出白色颜色的 fragment shader:

material = new THREE.ShaderMaterial({

        // ...

        fragmentShader: `
            void main()
            {
                gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
                #include <colorspace_fragment>
            }
        `
    })

所有 particles 都应该是白色的。

正如我们在之前的课程中看到的,#include <colorspace_fragment> 会以 sRGB 输出颜色,从而与 WebGLRenderer 的色彩空间匹配,改善色彩管理。

void main()
{
    // ...
    #include <colorspace_fragment>
}

你会在课程的后续部分了解到这个 #include 是什么。

将 shader 移到单独的文件中

现在是很好的时机,把 shader 移到单独的文件中,以免它们变得过长而难以管理。

在 /src/ 中创建一个 shaders/,然后在其中创建一个 galaxy/ 文件夹。

在该文件夹内,创建一个 vertex.glsl 文件,内容为 vertexShader 属性的内容:

void main()
{
    /**
     * Position
     */
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectedPosition = projectionMatrix * viewPosition;
    gl_Position = projectedPosition;

    /**
     * Size
     */
    gl_PointSize = 2.0;
}

再创建一个 fragment.glsl:

void main()
{
    gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}

项目配置已经支持 .glsl 文件。我们只需在 JavaScript 中导入它们,并在 material 中使用:

import galaxyVertexShader from './shaders/galaxy/vertex.glsl'
import galaxyFragmentShader from './shaders/galaxy/fragment.glsl'

// ...

material = new THREE.ShaderMaterial({
    // ...

    vertexShader: galaxyVertexShader,
    fragmentShader: galaxyFragmentShader
})

结果应该是一样的。

你并非必须把 shader 放到单独的文件中,但这是个好习惯,语法高亮还能帮你避免出错。

另外,如果你按照之前课程的建议安装了 linter,那么在刷新之前就能看到潜在的错误。

处理大小

基础大小

首先,我们为每个 particle 添加一个基础大小,并且希望能从 JavaScript 中更改这个值。为此,我们给 material 添加常见的 uniforms 属性,其中包含一个 uSize uniform:

material = new THREE.ShaderMaterial({

    // ...

    uniforms:
    {
        uSize: { value: 8 }
    },

    // ...
})

现在我们可以在 vertexShader 中获取这个值,并将其用于 gl_PointSize:

uniform float uSize;
        
void main()
{
    // ...

    gl_PointSize = uSize;
}

它们现在看起来挺大的,但很快就会变小。

随机化大小

在现实中,星星的大小各不相同。我们来添加一些随机性。我们希望为每个 vertex 关联一个不同的值。我们将使用一个 attribute。

给 geometry 添加一个 aScale attribute。我们已经有 position 和 color attribute,可以按照同样的方式轻松添加这个新的 attribute:

geometry = new THREE.BufferGeometry()

const positions = new Float32Array(parameters.count * 3)
const colors = new Float32Array(parameters.count * 3)
const scales = new Float32Array(parameters.count * 1)

// ...

for(let i = 0; i < parameters.count; i++)
{
    // ...

    // Scale
    scales[i] = Math.random()
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1))

在创建 Float32Array 和 BufferAttribute 时,务必使用 1 而不是 3,因为这个值是 float,不像其他的是 vec3——每个 vertex 只需要一个值。我们还将 attribute 命名为 aScale,在前面加了一个 a。

你可能会想把 position 和 color attribute 改成 aPosition 和 aColor,但这会导致 bug,因为我们使用的是 ShaderMaterial,它会在我们的 vertex shader 前面预置代码,这些代码会添加类似 attribute vec3 position; 和 attribute vec3 color; 的内容。

现在我们可以在 vertex shader 中获取这个 attribute,并用它乘以 uSize:

uniform float uSize;

attribute float aScale;

void main()
{
    // ...

    gl_PointSize = uSize * aScale;
}

你应该会看到大小随机的 particles。

修复像素比

然而,我们的 particles 有个问题。它们的大小取决于屏幕的像素比。还记得我们用下面这行代码更新了 renderer 的像素比:

renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

如果你使用的屏幕像素比为 1,那么 particles 看起来会比像素比为 2 的屏幕大 2 倍。

我们需要一个解决方案,让 particle 大小在任何像素比下都保持一致。

实现这一点有多种方法。最简单的方法是将 uSize 的值乘以 renderer 的像素比。我们可以用 getPixelRatio() 方法获取这个像素比:

material = new THREE.ShaderMaterial({

    // ...

    uniforms:
    {
        uSize: { value: 8 * renderer.getPixelRatio() }
    }

    // ...
})

不幸的是,这段代码不会生效,因为我们在创建 renderer 之前就创建了 material。要解决这个问题,只需将 generateGalaxy 的第一次调用移到实例化 renderer 之后:

/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})

// ...

/**
 * Generate the first galaxy
 */
generateGalaxy()

现在无论像素比如何,particles 看起来都一样了。

大小衰减

我们移除了 sizeAttenuation 属性,因为 ShaderMaterial 不支持它。我们需要自行实现这种大小衰减。

提醒一下,大小衰减会让远离 camera 的 particles 变小,靠近 camera 的变大。这会模拟透视效果。

与其尝试猜测获得正确大小的公式,我们不如直接到 Three.js 依赖文件夹中,在 PointsMaterial 代码里找到处理这部分逻辑的代码。

虽然 Three.js 库文件夹中有大量代码,但它组织良好、易于浏览。不要犹豫,花些时间去阅读并熟悉它。

你可以在 /node_modules/three/src/renderers/shaders/ShaderLib/point_vert.glsl.js 中找到处理这部分逻辑的 shader,它应该看起来像这样:

#ifdef USE_SIZEATTENUATION

    bool isPerspective = isPerspectiveMatrix( projectionMatrix );

    if ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );

#endif

我们唯一需要的部分是这一段:

gl_PointSize *= ( scale / - mvPosition.z );

为了实现大小衰减,我们需要用这个公式 ( scale / - mvPosition.z ) 乘以 gl_PointSize

根据 Three.js 的说法,scale 是一个与渲染高度相关的值。为了便于处理,我们可以将它替换为 1.0。

mvPosition 对应应用 modelMatrix 和 viewMatrix 后 vertex 的位置。在我们的例子中,它就是我们的 viewPosition 变量。

这听起来可能有点复杂,但我们可以这样写:

gl_PointSize = uSize * aScale;
gl_PointSize *= (1.0 / - viewPosition.z);

让 camera 靠近 particles,就能看到它们变大。我们实现了大小衰减。

绘制我们的 particle 图案

是时候绘制更好看的 particle 了。就像在 Shader Patterns 课程中一样,我们首先需要 UV 坐标。遗憾的是,我们无法将 UV 从 vertex shader 传递到 fragment shader。记住,vertex shader 控制每个 particle 的位置,而在 vertex 的位置会出现一个面向 camera 的正方形平面。

好消息是,在 fragment shader 中我们已经可以通过 gl_PointCoord 访问 UV。这个变量是 particles 特有的。

把它添加到 fragment shader 中看看结果:

void main()
{
    gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
}

你应该会在每个 particle 上看到常见的 UV 图案。

现在是尝试绘制一些星星形状的好时机。你可以从圆盘开始,然后是点光源,再然后何不试试卡通风格的星形,或者任何你想要的东西。请记住,做到这一点需要练习,你最初的尝试可能并不理想,但你仍会积累经验。

圆盘图案

要得到圆盘:

然后,我们将 strength 用于 r、g 和 b:

void main()
{
    // Disc
    float strength = distance(gl_PointCoord, vec2(0.5));
    strength = step(0.5, strength);
    strength = 1.0 - strength;

    gl_FragColor = vec4(vec3(strength), 1.0);
}

漫射点图案

要得到漫射点:

void main()
{
    // Diffuse point
    float strength = distance(gl_PointCoord, vec2(0.5));
    strength *= 2.0;
    strength = 1.0 - strength;

    gl_FragColor = vec4(vec3(strength), 1.0);
}

这样好多了,但仍然缺乏真实感。我们缺少的是一个非常明亮、但会迅速变暗的中心。

光点图案

要得到光点:

void main()
{
    // Light point
    float strength = distance(gl_PointCoord, vec2(0.5));
    strength = 1.0 - strength;
    strength = pow(strength, 10.0);

    gl_FragColor = vec4(vec3(strength), 1.0);
}

这个解决方案的好处是,我们可以通过 pow() 的值来控制光晕的聚集程度。

我们将采用这个图案。因为光看起来更小了,我们把 uSize 稍微调大一些:

material = new THREE.ShaderMaterial({
    
    // ...

    uniforms:
    {
        uSize: { value: 30 * renderer.getPixelRatio() }
    },

    // ...
})

不幸的是,对于某些电脑来说,我们正在接近性能极限,你可能会遇到帧率下降。如果是这样,请减少 particles 的数量或它们的大小。

处理颜色

在这个过程中,我们丢失了颜色。好消息是我们的 shader 部分支持这些颜色。我们只需使用它们的值即可。

要获取 color attribute,我们本应在 vertex shader 中写类似这样的代码:

attribute vec3 color;

因为我们使用的是 ShaderMaterial 而不是 RawShaderMaterial,所以不需要这样做。shader 编译时会自动在前面添加该代码。我们只需把它传给 fragment shader。为此,我们将使用一个名为 vColor 的 varying,并用 color attribute 更新该 varying:

// ...

varying vec3 vColor;

void main()
{
    // ...

    /**
     * Color
     */
    vColor = color;
}

然后我们可以在 fragment shader 中用相同的 varying 声明获取它,并根据 strength 在黑色和 vColor 之间使用 mix(...):

varying vec3 vColor;

void main()
{
    // Light point
    float strength = distance(gl_PointCoord, vec2(0.5));
    strength = 1.0 - strength;
    strength = pow(strength, 10.0);

    // Final color
    vec3 color = mix(vec3(0.0), vColor, strength);
    gl_FragColor = vec4(color, 1.0);
}

现在你能看到原始的颜色了。

动画

该做动画了。首先,我们使用常见的 uTime uniform。将它添加到 uniforms 中,并在 tick 函数中更新它的值:

material = new THREE.ShaderMaterial({

    // ...

    uniforms:
    {
        uTime: { value: 0 },
        uSize: { value: 30 * renderer.getPixelRatio() }
    },
    
    // ...
})

// ...

const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update material
    material.uniforms.uTime.value = elapsedTime

    // ...
}

然后我们可以把 uTime 添加到我们的 shader 中:

uniform float uTime;

这个动画会很普通。我们会让星星旋转,但越靠近中心,旋转越快。

以下代码发生在 vertex shader 中 modelPosition 声明之后。提醒一下,modelPosition 是应用了 mesh 的 position、rotation 和 scale 之后 vertex 的位置。我们现在必须更新这个变量。

以下是过程:

我们将使用一些三角函数。

旋转只发生在 x 和 z 轴上,我们可以保持 y 值不变,这大大简化了整个过程。

首先,用 atan(...) 获取角度:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);
                
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);

atan 代表 arc-tangent(反正切),你可以在这里了解更多:https://thebookofshaders.com/glossary/?search=atan

然后,用 length() 获取到中心的距离,它其实就是向量的长度:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);
                
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);

然后,我们计算一个偏移角度。正如之前所说,越靠近中心,角度越大。我们还把这个值乘以 uTime,再乘以 0.2 来减慢效果的速率:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);
                
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;

然后我们将这个 angleOffset 应用到基础的 angle 上:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);
                
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += angleOffset;

最后,我们用 cos(...) 和 sin(...) 更新 modelPosition 的 x 和 z 轴:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);
                
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += angleOffset;
modelPosition.x = cos(angle);
modelPosition.z = sin(angle);
lesson demo video preview

虽然这看起来很棒,但这并不是我们想要的结果。cos(...) 和 sin(...) 返回的是半径为 1 的圆上的位置。这就是为什么所有 particles 看起来像是在一个圆柱面上旋转。

要修复这个问题,我们只需把 cos(...) 和 sin(...) 乘以 vertex 的初始半径,这个值我们已经保存在 distanceToCenter 中:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);
                
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += angleOffset;
modelPosition.x = cos(angle) * distanceToCenter;
modelPosition.z = sin(angle) * distanceToCenter;
lesson demo video preview

所有 vertex 都应该漂亮地旋转起来。

修复随机性

如果你稍等一会,就会发现星星似乎形成了带状。就好像随机性在 x 和 z 轴上不再起作用了。这是由于旋转公式把星星拉伸成了自旋的图案。

要修复这个问题,我们可以从 position attribute 中移除随机性,把它保存到一个名为 aRandomness 的新 attribute 中。然后在 vertex shader 中旋转星星之后再应用这个随机性。

创建这个 attribute 并把随机性存入其中。别忘了从 positions 中移除随机性:

geometry = new THREE.BufferGeometry()

const positions = new Float32Array(parameters.count * 3)
const randomness = new Float32Array(parameters.count * 3)

// ...

for(let i = 0; i < parameters.count; i++)
{
    // ...

    positions[i3    ] = Math.cos(branchAngle) * radius
    positions[i3 + 1] = 0
    positions[i3 + 2] = Math.sin(branchAngle) * radius

    randomness[i3    ] = randomX
    randomness[i3 + 1] = randomY
    randomness[i3 + 2] = randomZ

    // ...
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('aRandomness', new THREE.BufferAttribute(randomness, 3))

// ...

在 vertex shader 中,获取这个 attribute,并在应用旋转之后把它加到 modelPosition 的 xyz 上:

// ...

attribute vec3 aRandomness;
attribute float aScale;

 // ...

void main()
{
    /**
     * Position
     */
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    
    // Rotate
    float angle = atan(modelPosition.x, modelPosition.z);
    float distanceToCenter = length(modelPosition.xz);
    float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
    angle += angleOffset;
    modelPosition.x = cos(angle) * distanceToCenter;
    modelPosition.z = sin(angle) * distanceToCenter;

    // Randomness
    modelPosition.xyz += aRandomness;

    // ...
}
lesson demo video preview

结果应该会好很多,带状也会消失。

减小随机性参数以获得更好的结果:

parameters.randomness = 0.2
lesson demo video preview

更进一步

你也可以把 uSize uniform 添加到调试面板中。

几分钟后,我们就无法完全分辨星系的旋臂了。你可以添加一个重置按钮,或者放慢速度。

星系中心通常有一个巨大的黑洞。何不尝试创建一个呢?